package ru.batrdmi.svnplugin.logic;

import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.vfs.VirtualFile;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.idea.svn.SvnVcs;
import org.tmatesoft.svn.core.*;
import org.tmatesoft.svn.core.internal.util.SVNDate;
import org.tmatesoft.svn.core.io.SVNRepository;
import org.tmatesoft.svn.core.wc.SVNInfo;
import org.tmatesoft.svn.core.wc.SVNRevision;

import java.util.*;

import static org.tmatesoft.svn.core.io.SVNRepository.INVALID_REVISION;

public class FileHistoryRetriever {
    private static final Logger log = Logger.getInstance("ru.batrdmi.svnplugin.logic.FileHistoryRetriever");

    private final SvnVcs svn;

    public FileHistoryRetriever(SvnVcs svn) {
        this.svn = svn;
    }

    public FileRevisionHistory getFileHistory(VirtualFile file, boolean extendedScan,
                                              final ProgressIndicator progressIndicator)
            throws SVNException {
        boolean retrievedWithErrors = false;
        final Set<Revision> revisions = new HashSet<Revision>();
        SVNInfo info = svn.getInfo(file);
        if (info == null) {
            throw new SVNException(SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "Error retrieving SVN information for file"));
        }
        SVNRevision committedRevision = info.getRevision();
        if (committedRevision == null) {
            throw new SVNException(SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "Error determining current file revision"));
        }
        boolean isDirectory = info.getKind() != SVNNodeKind.FILE;
        final long currentRevision = committedRevision.getNumber();
        String repoRoot = info.getRepositoryRootURL().toString();
        final String relPath = info.getURL().toString().substring(repoRoot.length());

        SVNRepository repo = svn.createRepository(repoRoot);

        showProgressInfo(progressIndicator, "Getting latest repository revision");
        long latestRevision = repo.getLatestRevision();

        ExtendedScanManager esm = new ExtendedScanManager(repo, latestRevision, progressIndicator);
        final List<Revision> deletedRevisions = new ArrayList<Revision>();
        final Queue<Revision> pathsToProcess = new LinkedList<Revision>();

        pathsToProcess.add(new Revision(relPath, currentRevision));
        while (!pathsToProcess.isEmpty()) {
            checkCancelled(progressIndicator);
            Revision task = pathsToProcess.remove();
            if (revisions.contains(task)) {
                continue;
            }
            final String targetPath = task.getRelPath();
            long pegRevision = task.getRevisionNumber();

            if (extendedScan) {
                pathsToProcess.addAll(esm.getAdditionalPaths(targetPath));
            }

            final List<Revision> tmpRevisions = new ArrayList<Revision>();
            try {
                showProgressInfo(progressIndicator, "Obtaining log info for " + targetPath);
                // find deleted revision for current scan point to determine limits of log request
                long deletedRevision = (pegRevision == latestRevision) ? INVALID_REVISION
                        : repo.getDeletedRevision(targetPath, pegRevision, latestRevision);
                if (deletedRevision >= 0) {
                    deletedRevisions.add(new Revision(targetPath, deletedRevision));
                }

                // request log for current scan point
                repo.log(new String[]{targetPath}, 0, deletedRevision < 0 ? INVALID_REVISION : (deletedRevision - 1),
                        true, true, 0, true, null,
                        new MergeInfoProcessor(targetPath, new LogEntryWithMergeInfoHandler() {
                            @Override
                            public void handleLogEntry(SVNLogEntry logEntry, @NotNull List<Revision> mergedRevisions)
                                    throws SVNCancelException {
                                checkCancelled(progressIndicator);
                                Revision.LinkType linkType = null;
                                // check copy sources
                                Revision copyFromRevision = getCopyFromRevision(logEntry, targetPath);
                                if (copyFromRevision != null) {
                                    linkType = Revision.LinkType.COPY;
                                    mergedRevisions = Arrays.asList(copyFromRevision);
                                } else if (!mergedRevisions.isEmpty()) {
                                    linkType = Revision.LinkType.MERGE;
                                }
                                long revision = logEntry.getRevision();
                                char changeType = getChangeType(logEntry, targetPath);
                                Revision rgRev = new Revision(targetPath, revision,
                                        logEntry.getAuthor(), logEntry.getDate(), logEntry.getMessage(),
                                        mergedRevisions, linkType, changeType);
                                tmpRevisions.add(rgRev);
                                showProgressInfo(progressIndicator, targetPath + "@" + revision + " processed");
                            }
                        }));

                showProgressInfo(progressIndicator, "Processing links for " + targetPath);
                // process retrieved log to save results and to extract links to other paths/revisions
                // to create new scan points from them
                for (Revision t : tmpRevisions) {
                    checkCancelled(progressIndicator);
                    long actualLinkedRevision = t.getLinkedRevisionNumber();
                    if (t.getLinkType() == Revision.LinkType.COPY) {
                        actualLinkedRevision = getActualFileRevision(repo, t.getLinkedRelPath(), actualLinkedRevision);
                        if (actualLinkedRevision < 0) {
                            log.error("Error finding copy source for " + t.getRelPath() + "@" + t.getRevisionNumber());
                            retrievedWithErrors = true;
                        }
                        revisions.add(new Revision(t.getRelPath(), t.getRevisionNumber(),
                                t.getAuthor(), t.getDate(), t.getMessage(),
                                Arrays.asList(new Revision(t.getLinkedRelPath(), actualLinkedRevision)),
                                t.getLinkType(), t.getChangeType()));
                    } else {
                        revisions.add(t);
                    }
                    if (actualLinkedRevision >= 0) {
                        pathsToProcess.add(new Revision(t.getLinkedRelPath(), actualLinkedRevision));
                    }
                    if (t.getChangeType() == SVNLogEntryPath.TYPE_REPLACED) {
                        long prevRevision = getActualFileRevision(repo, t.getRelPath(), t.getRevisionNumber() - 1);
                        if (prevRevision >= 0) {
                            pathsToProcess.add(new Revision(t.getRelPath(), prevRevision));
                        } else {
                            log.error("Error finding finding replaced revision for " + t.getRelPath() + "@" + t.getRevisionNumber());
                            retrievedWithErrors = true;
                        }
                    }
                }
            } catch (SVNCancelException e) {
                throw e;
            } catch (SVNException e) {
                log.error("Error processing revisions for " + task.getRelPath() + "@" + task.getRevisionNumber(), e);
                retrievedWithErrors = true;
            }
        }
        // determining actual current revision
        Revision currentRev = null;
        for (Revision r : revisions) {
            if (relPath.equals(r.getRelPath()) && r.getRevisionNumber() <= currentRevision
                    && (currentRev == null || currentRev.getRevisionNumber() < r.getRevisionNumber())) {
                currentRev = r;
            }
        }

        showProgressInfo(progressIndicator, "Adding deleted revisions");
        // adding deleted revisions
        for (Revision r : deletedRevisions) {
            checkCancelled(progressIndicator);
            if (!revisions.contains(r)) {
                SVNProperties p = repo.getRevisionProperties(r.getRevisionNumber(), null);
                revisions.add(new Revision(r.getRelPath(), r.getRevisionNumber(),
                        p.getStringValue(SVNRevisionProperty.AUTHOR),
                        SVNDate.parseDateString(p.getStringValue(SVNRevisionProperty.DATE)),
                        p.getStringValue(SVNRevisionProperty.LOG),
                        Collections.<Revision>emptyList(), null, SVNLogEntryPath.TYPE_DELETED));
            }
        }
        return new FileRevisionHistory(revisions, currentRev, repoRoot, relPath, isDirectory, retrievedWithErrors);
    }

    private static long getActualFileRevision(SVNRepository repo, String relPath, long revision) throws SVNException {
        final long[] actualRevision = new long[] {INVALID_REVISION};
        repo.log(new String[]{relPath}, revision, 1, false, true, 1, new ISVNLogEntryHandler() {
            @Override
            public void handleLogEntry(SVNLogEntry logEntry) throws SVNException {
                actualRevision[0] = logEntry.getRevision();
            }
        });
        long result = actualRevision[0];
        if (result < 0) {
            log.error(relPath + "@" + revision + " not found in repository");
        }
        return result;
    }

    private static boolean isAffectingPath(String targetPath, String changePath) {
        return targetPath.startsWith(changePath);
    }

    @SuppressWarnings("unchecked")
    private static Revision getCopyFromRevision(SVNLogEntry le, String targetPath) {
        Map<String, SVNLogEntryPath> changedPaths = le.getChangedPaths();
        String longestAffectingPath = "";
        Revision result = null;
        for (SVNLogEntryPath logEntryPath : changedPaths.values()) {
            String path = logEntryPath.getPath();
            String copyPath = logEntryPath.getCopyPath();
            if (copyPath != null && isAffectingPath(targetPath, path)
                    && path.length() >= longestAffectingPath.length()) {
                String copyFromPath = copyPath + targetPath.substring(path.length());
                long copyFromRevision = logEntryPath.getCopyRevision();
                result = new Revision(copyFromPath, copyFromRevision);
                longestAffectingPath = path;
            }
        }
        return result;
    }

    @SuppressWarnings("unchecked")
    private static char getChangeType(SVNLogEntry le, String targetPath) {
        String longestAffectingPath = "";
        boolean targetPathWasAdded = false;
        char changeType = SVNLogEntryPath.TYPE_MODIFIED;
        // finding change type for most 'precise' entry path (target path itself or nearest enclosing folder)
        Map<String, SVNLogEntryPath> changedPaths = le.getChangedPaths();
        for (SVNLogEntryPath logEntryPath : changedPaths.values()) {
            String entryPath = logEntryPath.getPath();
            char entryChangeType = logEntryPath.getType();
            if (targetPath.startsWith(entryPath)) {
                if (entryChangeType == SVNLogEntryPath.TYPE_ADDED) {
                    targetPathWasAdded = true;
                }
                if (entryPath.length() >= longestAffectingPath.length()) {
                    changeType = entryChangeType;
                    longestAffectingPath = entryPath;
                }
            }
        }
        // correction for SVN 'feature': R type doesn't always indicate replacement
        if (changeType == SVNLogEntryPath.TYPE_REPLACED && targetPathWasAdded) {
            changeType = SVNLogEntryPath.TYPE_ADDED;
        }
        return changeType;
    }

    @SuppressWarnings("unchecked")
    private static Revision guessMergedRevision(String targetPath, SVNLogEntry mergedRevision) {
        Map<String, SVNLogEntryPath> changedPaths = mergedRevision.getChangedPaths();
        for (SVNLogEntryPath lep : changedPaths.values()) {
            String[] targetSplit = FileNameUtil.splitPath(targetPath);
            String[] mergeSplit = FileNameUtil.splitPath(lep.getPath());
            if (targetSplit != null && mergeSplit != null && !targetSplit[1].equals(mergeSplit[1])) {
                return new Revision(mergeSplit[0] + mergeSplit[1] + targetSplit[2], mergedRevision.getRevision());
            }
        }
        return null;
    }

    private static void showProgressInfo(ProgressIndicator progressIndicator, String message) {
        if (progressIndicator != null) {
            progressIndicator.setText2(message);
        }
    }

    private static void checkCancelled(ProgressIndicator progressIndicator) throws SVNCancelException {
        if (progressIndicator.isCanceled()) {
            throw new SVNCancelException();
        }
    }

    private static interface LogEntryWithMergeInfoHandler {
        void handleLogEntry(SVNLogEntry logEntry, @NotNull List<Revision> mergedRevision) throws SVNCancelException;
    }

    private class MergeInfoProcessor implements ISVNLogEntryHandler {
        private final LogEntryWithMergeInfoHandler consumer;
        private final String relativePath;
        private int depth = 0;
        private SVNLogEntry savedEntry = null;
        private List<Revision> mergedRevisions;

        public MergeInfoProcessor(String relativePath, LogEntryWithMergeInfoHandler consumer) {
            this.relativePath = relativePath;
            this.consumer = consumer;
            mergedRevisions = new ArrayList<Revision>();
        }

        @Override
        @SuppressWarnings("unchecked")
        public void handleLogEntry(SVNLogEntry logEntry) throws SVNException {
            if (depth == 0) {
                savedEntry = logEntry;
            } else if (depth == 1 && logEntry.getRevision() != INVALID_REVISION) {
                Revision mergedRevision = guessMergedRevision(relativePath, logEntry);
                if (mergedRevision != null) {
                    mergedRevisions.add(mergedRevision);
                }
            }
            if (logEntry.hasChildren()) {
                depth++;
            } else if (logEntry.getRevision() == INVALID_REVISION) {
                depth--;
            }
            if (depth == 0) {
                consumer.handleLogEntry(savedEntry, mergedRevisions);
                mergedRevisions = new ArrayList<Revision>();
            }
        }
    }

    private static class ExtendedScanManager {
        final SVNRepository repo;
        final long revision;
        final ProgressIndicator progressIndicator;
        final Set<String> processedTrunkPaths;
        final Map<String,Collection<String>> modulePaths;

        public ExtendedScanManager(SVNRepository repo, long revision, ProgressIndicator progressIndicator) {
            this.repo = repo;
            this.revision = revision;
            this.progressIndicator = progressIndicator;
            this.processedTrunkPaths = new HashSet<String>();
            this.modulePaths = new HashMap<String, Collection<String>>();
        }

        public Collection<Revision> getAdditionalPaths(String relPath) throws SVNCancelException {
            Collection<Revision> result = new ArrayList<Revision>();

            String[] pathSplit = FileNameUtil.splitPath(relPath);
            if (pathSplit == null) {
                log.info("Couldn't determine branch/tag locations for " + relPath + ", will skip extended scan for it");
                return result;
            }
            String trunkPath = pathSplit[0] + "/trunk" + pathSplit[2];
            if (!processedTrunkPaths.add(trunkPath)) {
                return result;
            }

            showProgressInfo(progressIndicator, "Searching for " + relPath + " in all branches/tags");

            Collection<String> principalPaths = getPrincipalPathsForModule(pathSplit[0]);
            for (String p : principalPaths) {
                checkCancelled(progressIndicator);
                String path = p + pathSplit[2];
                try {
                    long actualRev = getActualFileRevision(repo, path, revision);
                    if (actualRev >= 0) {
                        result.add(new Revision(path, actualRev));
                    }
                } catch (SVNException e) {
                    log.info(path + "@" + revision + " not found in repository");
                }
            }
            return result;
        }

        private Collection<String> getPrincipalPathsForModule(String module) {
            Collection<String> paths = modulePaths.get(module);
            if (paths != null) {
                return paths;
            }
            paths = new ArrayList<String>();
            paths.add(module + "/trunk");
            Collection<SVNDirEntry> entries = new ArrayList<SVNDirEntry>();
            try {
                String branchesPath = module + "/branches/";
                repo.getDir(branchesPath, revision, false, entries);
                for (SVNDirEntry de : entries) {
                    if (de.getKind() == SVNNodeKind.DIR) {
                        paths.add(branchesPath + de.getRelativePath());
                    }
                }
            } catch (SVNException e) {
                log.warn("Error finding existing branches for module " + module, e);
            }
            entries.clear();
            try {
                String tagsPath = module + "/tags/";
                repo.getDir(tagsPath, revision, false, entries);
                for (SVNDirEntry de : entries) {
                    if (de.getKind() == SVNNodeKind.DIR) {
                        paths.add(tagsPath + de.getRelativePath());
                    }
                }
            } catch (SVNException e) {
                log.warn("Error finding existing tags for module " + module, e);
            }
            modulePaths.put(module, paths);
            return paths;
        }
    }
}
